Spring DI
contents
오늘은 스프링에서 빈에 의존성을 주입하는 3가지 방식을 비교해보겠습니다.
세 가지 방식 모두 '빈에 의존성을 넣는다'는 결과는 같지만, 설계의 품질, 테스트 용이성, 불변성(Immutability) 측면에서 엄청난 차이가 있습니다.
1. 필드 주입 (Field Injection) - "안티 패턴"
초보자들이 가장 많이 쓰는 방식입니다. 코드가 간결하기 때문입니다. 필드 위에 @Autowired만 붙이면 끝납니다.
@Service
public class UserService {
@Autowired // ⚠️ 필드 주입 사용
private UserRepository userRepository;
public void register() {
userRepository.save(new User());
}
}
장점
- 간결함: 작성할 코드가 거의 없습니다. 보일러플레이트(상용구) 코드가 없습니다.
- 가독성 (표면적): 한눈에 보기에 깔끔해 보입니다.
단점 및 문제점
- 불변성 위반: 필드를
final로 선언할 수 없습니다. 즉, 객체가 생성된 후에userRepository가 실수로 변경될 가능성이 열려 있습니다. - Spring과 강한 결합: 이 클래스는 스프링 컨테이너 없이는 무용지물입니다. 필드가
private이고 세터(Setter)나 생성자가 없으므로, 순수 자바 코드로new UserService()를 해서 테스트하려 해도userRepository에 값을 넣어줄 방법이 없습니다. - 테스트의 악몽: 단위 테스트(Unit Test)를 작성하려면 리플렉션(Reflection) 을 쓰거나, 메서드 하나 테스트하자고 무거운 스프링 컨텍스트(
@SpringBootTest)를 전부 띄워야 합니다. 단순히 Mock 객체를 넣어줄 수가 없습니다. - 의존성 숨김: 만약 클래스가 10개의 의존성을 가진다면, 생성자 주입은 생성자가 너무 길어져서 "이 클래스가 너무 많은 일을 하는구나(단일 책임 원칙 위반)"를 바로 알 수 있습니다. 하지만 필드 주입은 이를 숨기기 때문에 코드를 다 훑어보기 전에는 복잡도를 파악하기 어렵습니다.
2. 수정자 주입 (Setter Injection) - "선택적 의존성"
public 세터 메서드를 통해 의존성을 주입합니다.
@Service
public class UserService {
private UserRepository userRepository;
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
장점
- 선택적(Optional) 의존성: 수정자 주입의 유일한 올바른 사용처입니다. 해당 의존성이 없어도 클래스가 동작하는 데 문제가 없는 경우에만 사용합니다.
- 재설정 가능: 이론적으로 런타임에 의존성을 교체할 수 있습니다 (하지만 이는 위험하고 거의 사용되지 않습니다).
단점 및 문제점
- 불완전한 초기화: 세터를 호출하지 않고도
UserService객체를 생성할 수 있습니다. 그 상태에서register()를 호출하면NullPointerException이 발생합니다. 객체가 불안정한 상태로 만들어질 수 있습니다. - 불변성 없음: 필드 주입과 마찬가지로
final을 사용할 수 없습니다. - 번거로움: 세터 메서드를 일일이 작성해야 해서 코드가 길어집니다.
3. 생성자 주입 (Constructor Injection) - "표준(Gold Standard)"
Spring 4.3 이후부터 강력하게 권장되는 방식입니다. 생성자를 통해 의존성을 전달받습니다.
@Service
public class UserService {
private final UserRepository userRepository; // ✅ Final 사용 가능!
// 생성자가 하나만 있으면 @Autowired 생략 가능
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
장점
- 불변성 (Immutability):
final키워드를 사용할 수 있습니다. 빈이 한번 생성되면 의존성은 절대 바뀌지 않음을 보장합니다. (스레드 안전성 확보) - 초기화 보장:
UserRepository없이는UserService를 아예 생성할 수 없습니다. 컴파일 에러가 발생하므로, 런타임NullPointerException을 원천 차단합니다. - POJO (순수 자바 객체): 스프링 애노테이션 없이도 완벽하게 동작하는 순수 자바 클래스입니다.
- 쉬운 테스트: 순수 자바 코드로 테스트하기 쉽습니다:
// 스프링 없이 순수 자바 단위 테스트 가능!
UserRepository mockRepo = Mockito.mock(UserRepository.class);
UserService service = new UserService(mockRepo); // 그냥 Mock을 넣어주면 끝
단점
- 코드량 (해결됨): 예전에는 생성자를 직접 쓰는 게 귀찮았습니다. 하지만 Lombok 덕분에 이 문제는 사라졌습니다.
현대적인 방식 (Lombok 활용):
@Service
@RequiredArgsConstructor // ✅ 모든 'final' 필드에 대한 생성자를 자동 생성
public class UserService {
private final UserRepository userRepository;
}
4. 심층 분석: "순환 참조(Circular Dependency)" 문제
이것이 기술적으로 가장 큰 차이점입니다.
시나리오: Bean A가 Bean B를 필요로 하고, Bean B가 Bean A를 필요로 하는 상황.
Case A: 필드/수정자 주입 사용 시
스프링은 Bean A 생성 Bean B 필요함 확인 Bean B 생성 Bean A 필요함 확인.
- 결과: 스프링은 Bean A를 먼저 만들고, 그 안에 비어있는 B의 "프록시(껍데기)"를 주입한 뒤, 나중에 실제 B를 채워 넣는 식으로 어떻게든 실행되게 만듭니다.
- 위험: 애플리케이션은 켜지지만, 나중에 서로를 호출하는 로직이 실행될 때
StackOverflowError가 터지거나 예기치 않은 동작을 할 수 있습니다. (시한폭탄)
Case B: 생성자 주입 사용 시
스프링이 Bean A 생성 시도 생성자가 Bean B 요구 Bean B 생성 시도 생성자가 Bean A 요구.
- 결과:
BeanCurrentlyInCreationException발생. 애플리케이션이 시작조차 되지 않고 죽습니다. - 이게 왜 좋은가?: 생성자 주입은 잘못된 설계를 즉시 드러냅니다. 순환 참조가 있다는 것은 설계가 잘못되었다는 뜻입니다. (예: 공통 로직을 제3의 Bean C로 분리해야 함).
5. 요약 테이블
| 특징 | 필드 주입 (Field) | 수정자 주입 (Setter) | 생성자 주입 (Constructor) |
|---|---|---|---|
| 가독성 | 높음 (가장 깔끔) | 낮음 (장황함) | 높음 (Lombok 사용 시) |
불변성 (final) |
❌ 불가 | ❌ 불가 | ✅ 가능 |
| 신뢰성 | ⚠️ 낮음 (NPE 위험) | ⚠️ 낮음 (NPE 위험) | ✅ 높음 (컴파일 타임 안전) |
| 테스트 용이성 | ❌ 어려움 (리플렉션 필요) | ⚠️ 보통 | ✅ 쉬움 (POJO) |
| 순환 참조 처리 | 숨겨버림 (나쁨) | 숨겨버림 (나쁨) | 즉시 실패 (좋음) |
| 스프링 권고 | 비권장 / 지양 | 선택적 의존성에만 사용 | 강력 추천 |
주니어 개발자를 위한 제언
무조건 생성자 주입을 사용하세요. (특히 Lombok의 @RequiredArgsConstructor와 함께)
NullPointerException을 예방합니다.- 단위 테스트 작성이 훨씬 쉬워집니다.
- IntelliJ IDEA도 필드 주입을 쓰면 "Field injection is not recommended"라고 경고를 띄웁니다.
references